Skip to content

feat(territories): role-gated attribution per territory kind - #365

Merged
mindsers merged 3 commits into
mainfrom
feat/role-gated-territory-attribution
Aug 25, 2026
Merged

feat(territories): role-gated attribution per territory kind#365
mindsers merged 3 commits into
mainfrom
feat/role-gated-territory-attribution

Conversation

@mindsers

Copy link
Copy Markdown
Contributor

Closes #147 — phase 5 of the role-based permission epic (#142).

Each territory kind now declares which roles a publisher must hold to be attributed a territory of that kind. The attribution pickers filter to matching publishers, and the server rejects a non-matching one posted directly at the route.

Two commits

chore(territories): rename TerritoryKind enum to TerritoryKindKey — mechanical, no behaviour change. Frees the name for the entity table. Enum values and their @map strings are unchanged, so Territory.type keeps its data and the migration is a Postgres type rename.

feat(territories): role-gated attribution per territory kind — the feature.

Design notes

The TerritoryKind entity, not an enum-keyed join. #147 specced TerritoryKindRole(kind, congregationId, roleId). Since kinds will eventually be user-created, a Prisma enum can't hold them, so this introduces the real table and hangs the role links off it: when custom kinds land, that table grows a name/slug and drops the enum column, and the join rows are untouched. Territory.type deliberately stays on TerritoryKindKey — pointing it at the FK is a later change.

The gate lives in the createAttribution / updateAttribution delegators, not in the aggregate. Those two are called only by the attribution routes, so a direct POST cannot slip past — while campaign-lifecycle.workflow, which calls attributionAggregate.assign directly, stays exempt by construction. That sweep re-attributes a pairing that already existed and swallows ConflictError, so gating it would silently strip a publisher's territory when a kind's roles change. An integration test guards that exemption.

An unchanged publisher on an edit passes. Otherwise tightening a kind's roles would make every existing attribution unsavable. The edit picker keeps the current publisher listed for the same reason.

What landed

  • TerritoryKind + TerritoryKindAllowedRole with RLS policies and a compound FK so a kind can never be linked across tenants. The migration seeds the five built-ins for existing congregations; seedBuiltInTerritoryKinds covers new ones, injected into seedCongregationDefaults the way seedTemplates already was.
  • Services written test-first: territory-kinds.server.ts, territory-kinds.queries.ts, attributable-publishers.queries.ts, attribution-eligibility.policy.ts. Eligibility resolves through findMembersWithAnyRole, the canonical helper that unions identity roles on MemberRoleAssignment with the account's custom roles.
  • Settings → Territoires: "Types de territoires" is now one row per kind with a RolePicker; the Phone activation switch moved into its own row. Extracting DurationInput alongside it left settings.tsx at 314 lines, under the 351 it was grandfathered at.
  • Archive parity: both tables round-trip through export/import. IMPORT_TOTAL_STEPS 41 → 43, caught by its own guard test.
  • New AuditAction.TerritoryKindAllowedRolesChanged, emitted with the added/removed diff.

Verification

3153 unit tests, 334 integration tests, lint, typecheck, and all six architecture guards pass. 11 new integration tests cover persistence, RLS isolation across congregations, cascade when a role is deleted, seeding, and the campaign-sweep exemption.

Browser QA against the demo congregation: the picker dropped 45 publishers to the 5 elders on a restricted Phone territory; an unrestricted kind still listed everyone; a direct POST with a non-elder returned 409 with the field error and wrote nothing; an unchanged non-elder saved fine; changing to another non-elder was rejected.

Out of scope

  • Per-kind enable/disable switches. Only Phone has an activation setting today (phone-territory-active, read in 16 places); moving it onto TerritoryKind.active is a follow-up that has to rewire those call sites.
  • Pointing Territory.type at the new FK, and user-created kinds.
  • Per-territory role overrides (the issue's option 2).

Worth a follow-up

The settings page renders the full role chip list once per kind. On a congregation with ~20 roles that is a tall wall of chips — a collapsed-by-default row would read better.

Frees the TerritoryKind name for the entity table that will own per-kind
configuration and, later, user-created kinds. Enum values and their mapped
strings are unchanged, so Territory.type keeps its data — the migration is a
Postgres type rename, not a data migration.
Each territory kind now declares which roles a publisher must hold to be
attributed a territory of that kind. Closes the last open piece of the
role-based permission epic besides the direct-grant cutover.

Introduces the TerritoryKind entity rather than keying the join table on the
enum, so the configuration survives kinds becoming user-created: that table
grows a name/slug and drops the enum column, and the role links are untouched.
Territory.type deliberately stays on TerritoryKindKey for now.

The gate lives in the createAttribution / updateAttribution delegators, not in
the aggregate. Those two are the human-initiated path, so posting straight at
the route cannot slip past it, while campaign-lifecycle.workflow — which calls
the aggregate directly — stays exempt: it re-attributes a pairing that already
existed and swallows ConflictError, so gating it would silently strip a
publisher's territory when a kind's roles change. An unchanged publisher on an
edit also passes, so tightening a kind never locks an existing attribution.

Refs #147
Review of #365 surfaced a fail-open path: kindKey was typed `string`, so a
mistyped key found no kind, getKindAllowedRoleIds returned [] — which means
"unrestricted" — and the gate silently passed. Every call site already holds a
TerritoryKindKey (Territory.type, or Object.values in the settings action), so
narrowing costs nothing now and the compiler rejects the typo instead. Widen it
back to string when congregations can define their own kinds, not before.

Also:
- Fix an inaccurate comment: BUILT_IN_TERRITORY_KIND_KEYS holds the enum member
  names, not the @Map strings, and it contradicted the migration that says so.
- Guard the hand-spelled kind-roles-* settings fields against drifting from the
  enum; without it, adding a kind silently drops its roles on every save.
- Cover the unchanged-publisher edit path end-to-end, not just at unit level.
- Drop two tests that asserted on delegation instead of observable outcomes.
@mindsers
mindsers merged commit 74a94dd into main Aug 25, 2026
7 checks passed
@mindsers
mindsers deleted the feat/role-gated-territory-attribution branch August 25, 2026 00:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(territories): role-gated attribution per kind

1 participant